Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FHIRStore actor isolation #26

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open

FHIRStore actor isolation #26

wants to merge 7 commits into from

Conversation

philippzagar
Copy link
Member

@philippzagar philippzagar commented Dec 23, 2024

FHIRStore actor isolation

♻️ Current situation & Problem

As references in #25, the current FHIRStore implementation is not ideal as it still uses preconcurrency locking mechanisms. Therefore, the FHIRStore should be reimplemented to use Swift's structured concurrency and isolation mechanisms.

⚙️ Release Notes

  • FHIRStore actor isolation

📚 Documentation

Documented all isolation thoughts

✅ Testing

--

📝 Code of Conduct & Contributing Guidelines

By submitting creating this pull request, you agree to follow our Code of Conduct and Contributing Guidelines:

@philippzagar philippzagar added the enhancement New feature or request label Dec 23, 2024
@philippzagar philippzagar self-assigned this Dec 23, 2024
Copy link
Collaborator

@jdisho jdisho left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome job @philippzagar ⚡️⚡️⚡️

Fyi, I left some suggestions. But these are more or less like nice-to-have ones.

Sources/SpeziFHIR/FHIRStore.swift Outdated Show resolved Hide resolved
Sources/SpeziFHIR/FHIRStore.swift Show resolved Hide resolved
Sources/SpeziFHIR/FHIRStore.swift Outdated Show resolved Hide resolved
Copy link

codecov bot commented Dec 23, 2024

Codecov Report

Attention: Patch coverage is 93.84615% with 8 lines in your changes missing coverage. Please review.

Project coverage is 34.93%. Comparing base (a2e68a2) to head (d9ef492).

Files with missing lines Patch % Lines
...eziFHIRMockPatients/FHIRStore+TestingSupport.swift 0.00% 3 Missing ⚠️
Sources/SpeziFHIR/FHIRStore.swift 97.30% 2 Missing ⚠️
...urces/SpeziFHIRHealthKit/FHIRStore+HealthKit.swift 0.00% 2 Missing ⚠️
...ces/SpeziFHIRMockPatients/FHIRBundleSelector.swift 97.44% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #26      +/-   ##
==========================================
+ Coverage   33.50%   34.93%   +1.44%     
==========================================
  Files          21       21              
  Lines        1466     1466              
==========================================
+ Hits          491      512      +21     
+ Misses        975      954      -21     
Files with missing lines Coverage Δ
...SpeziFHIR/FHIRResource/FHIRResource+Category.swift 36.70% <100.00%> (-0.70%) ⬇️
Sources/SpeziFHIR/FHIRResource/FHIRResource.swift 16.91% <ø> (+6.34%) ⬆️
...ces/SpeziFHIRMockPatients/FHIRBundleSelector.swift 96.56% <97.44%> (+2.68%) ⬆️
Sources/SpeziFHIR/FHIRStore.swift 98.10% <97.30%> (+8.58%) ⬆️
...urces/SpeziFHIRHealthKit/FHIRStore+HealthKit.swift 0.00% <0.00%> (ø)
...eziFHIRMockPatients/FHIRStore+TestingSupport.swift 0.00% <0.00%> (ø)

Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update a2e68a2...d9ef492. Read the comment docs.

Copy link
Member

@Supereg Supereg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had some comments regarding the concurrency design of the Storage type. I'm definitely missing the bigger pictures of all the inner workings, so feel free to comment on my concerns.

Comment on lines 121 to 125
public func insert(resource: FHIRResource) async {
_$observationRegistrar.willSet(self, keyPath: resource.storeKeyPath)
await storage.insert(resource: resource)
_$observationRegistrar.didSet(self, keyPath: resource.storeKeyPath)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling observation registrar from non-MainActor might currently crash SwiftUI when running iOS 18 and Swift 6 Language mode. We should make sure to call these on the main actor.
Did you test that doing an async mutation works with SwiftUI. I had issues in the past that would lead me to believe that SwiftUI marks the views dirty on the willSet and would therefore miss view updates.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting that this apparently(?) produces crashes in iOS 18 with Swift 6 language mode. Is that documented anywhere or is there a SO post? (except for your comment in the Swift forums)
I never experienced a crash in regards to the observation registrar in my testing, and I performed tons of bulk insertions (see UI tests).
Still, trusting your expertise (and as all of these functions are now on the MainActor already), I'll perform the mutation on the MainActor.

Yes, this behavior stayed the same here, even added an additional test case just to be sure.

Comment on lines 25 to 27
// Non-isolation required so that resource access via the `FHIRStore` stays sync (required for seamless SwiftUI access).
// Isolation is still guaranteed as the only modifying functions `insert()` and `remove()` are isolated on the `FHIRStore.Storage` actor.
nonisolated(unsafe) private var _resources: [FHIRResource] = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronizing modifications to a single task but reading from other threads might still lead to race conditions as CPU caches might render outdated information (or out of order issues). The previous lock implementation would have protected against this as it makes sure that shared state is propagated to all CPU caches.
Just using a lock, however, doesn't prevent inconsistent reads. E.g., a view might read this property twice in its view body potentially receiving two different values (e.g., a isEmpty check might read the property a second time). Either, we need to make this behavior explicit (e.g., as part of documentation or through API design).
Most importantly, FHIRResource is not Sendable. So this is unsafe by definition and is all hidden by the nonisolated(unsafe). FHIRResources cannot be shared between two actors (they might be sent, however we always keep a reference through this array. So this is impossible to be provable correct by the compiler).

As it seems this can be semantically considered a view model, why not just synchronize to the MainActor? We might do initial population from the array on a different actor (if necessary?), as we can send the result to the array that is isolated on, e.g., MainActor.

Copy link
Member Author

@philippzagar philippzagar Jan 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for giving me more input here, I wasn't aware of all of these caching issues with Swift isolation that you mentioned.
I marked the store as Sendable as mutations are only done via the synchronized insert / delete methods on the actor, just the read operation is done concurrently. I assumed that this makes it safe to pass around between different actors. But apparently, caching issues prevent that from being safe? Am I reading this right? Otherwise, I don't see a reason why this couldn't be Sendable.

I followed your suggestion of synchronizing most properties on the MainActor, except for the heavy lifting of ingesting new resources into the store, that's done on an arbitrary actor. That gives us a rather clean and still safe solution, which should also be performant. Lmk what you think

@philippzagar philippzagar requested a review from Supereg January 2, 2025 23:23
Copy link
Member

@Supereg Supereg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had two final comments.

LGTM 🚀

guard let resource = _resources.first(where: { $0.id == resourceId }) else {
return
/// - Parameter resources: The `FHIRResource`s to be inserted.
public func insert<T: Collection>(resources: sending T) async where T.Element == FHIRResource {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make this method just be isolated to MainActor (and sync) instead of requiring that the input must be supplied sending? Seems a bit overly restrictive?

_$observationRegistrar.didSet(self, keyPath: \.conditions)
_$observationRegistrar.didSet(self, keyPath: \.diagnostics)
_$observationRegistrar.didSet(self, keyPath: \.encounters)
_$observationRegistrar.didSet(self, keyPath: \.immunizations)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something that this PR is changing is that previously the observation pattern is much more granular. If you were just interested in a certain category, the view would just update if an element in this category was changed. Might this impact performance for our use case?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
Status: Backlog
Development

Successfully merging this pull request may close these issues.

4 participants